# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

"""
NotifyIcon.py
By: Denver Coneybeare <denver@sleepydragon.org>
Oct 18, 2012
"""

from __future__ import print_function
from __future__ import unicode_literals

from Balloon import Balloon
from Error import Error

from winapi_constants import CW_USEDEFAULT
from winapi_constants import NIF_GUID
from winapi_constants import NIF_ICON
from winapi_constants import NIF_INFO
from winapi_constants import NIF_REALTIME
from winapi_constants import NIF_SHOWTIP
from winapi_constants import NIF_TIP
from winapi_constants import NIIF_ERROR
from winapi_constants import NIIF_INFO
from winapi_constants import NIIF_LARGE_ICON
from winapi_constants import NIIF_NONE
from winapi_constants import NIIF_NOSOUND
from winapi_constants import NIIF_RESPECT_QUIET_TIME
from winapi_constants import NIIF_USER
from winapi_constants import NIIF_WARNING
from winapi_constants import NIM_ADD
from winapi_constants import NIM_DELETE
from winapi_constants import NIM_MODIFY
from winapi_constants import NIM_SETVERSION

class NotifyIcon(object):
    """
    Show, hide, and modify an icon in the Windows notification area.
    Instances of this class have several attributes (documented below) that can
    be set in order to control what the icon looks like in the notification
    area.  When a new instance of this class is created, it is not automatically
    shown.  To show the icon, invoke show().  If the icon is already shown, it
    can be updated to reflect changes to attributes in this class using the
    update() method.  To remove the icon from the notification area, invoke
    hide().

    When an instance of this class is destroyed, its icon is removed from the
    notification area by an invocation of hide().  However, do not rely on this
    behaviour, and invoke hide() explicitly if at all possible, as it is not
    entirely guaranteed by the Python interpreter to call __del__(), the method
    responsible for this "cleanup".
    """

    def __init__(self, id, tooltip=None, icon=None, balloon=None, winapi=None):
        """
        Initializes a new instance of this class.
        *id* must be a byte string of length exactly 16, which will be used as
        a unique identifier for the icon.
        *tooltip* must be a Unicode string whose value will be used as the
        tooltip for the icon in the notification area; this value may be changed
        by setting self.tooltip to another value; assert_valid_tooltip(tooltip)
        will be called and any exceptions it raises will be raised from this
        method; may be None (the default) for no tooltip.
        *icon* must be an Icon object and will be used as the icon in the
        notification area; may be None (the default) to not use any icon.
        *balloon* must be a Balloon object, and will be the balloon notification
        displayed by this object; may be None (the default) to not display a
        balloon notification.
        *winapi* must be the winapi module or a compatible object and will be
        used to perform the low-level operations required by this class; it is
        recommended to simply specify None (the default) in order to use the
        winapi module directly; this argument is primarily intended for use
        during unit testing of this class, and should not normally be explicitly
        specified.
        Raises TypeError if id is not a byte string and ValueError if the length
        of id is not exactly 16; this is done by invoking assert_valid_id(id).
        """
        if winapi is None:
            import winapi as winapi_module
            winapi = winapi_module
        self.winapi = winapi

        self.assert_valid_id(id)
        if tooltip is not None:
            self.assert_valid_tooltip(tooltip)

        self.id = id
        self.tooltip = tooltip
        self.icon = icon
        self.balloon = balloon

        # setting self.window_hwnd LAST is important, because __del__() uses its
        # presence to determine whether or not __init__() completed successfully
        self.window_hwnd = None


    def __del__(self):
        """
        Removes this icon from the notification area and destroys its
        parent window.
        """
        try:
            window_hwnd = self.window_hwnd
        except AttributeError:
            pass # __init__() did not complete; nothing to do
        else:
            self.window_hwnd = None
            # NOTE: if window_hwnd==None indicates that the icon was never added
            if window_hwnd is not None:
                try:
                    self.remove(ignore_errors=True)
                finally:
                    self.winapi.DestroyWindow(window_hwnd)


    @staticmethod
    def assert_valid_id(value):
        """
        Tests an object for validity as an "id" of this object.
        Raises TypeError if the given value is not a str object or raises
        ValueError if its length is not exactly 16.
        """
        if not isinstance(value, str):
            raise TypeError("invalid type for id: {} (expected str)"
                .format(type(value)))

        value_length = len(value)
        if value_length != 16:
            raise ValueError("invalid length for id: {} (expected 16)"
                .format(value_length))


    def assert_valid_tooltip(self, value):
        """
        Tests an object for validity as a "tooltip" of this object.
        Raises TypeError if the given value is not a Unicode string or raises
        ValueError if its length (in characters) exceeds the maximum length
        returned from self.tooltip_max_length().
        """
        if not isinstance(value, unicode):
            raise TypeError("invalid type for tooltip: {} (expected unicode)"
                .format(type(value)))

        max_length = self.tooltip_max_length()
        value_length = len(value)
        if value_length > max_length:
            raise ValueError("invalid length for tooltip: {} (must be less "
                "than or equal to {})".format(value_length, max_length))


    def tooltip_max_length(self):
        """
        Returns the maximum length (in characters) of the "tooltip" string
        of this object.  Returns an int.
        """
        result = self.winapi.NOTIFYICONDATA.SZTIP_NUM_CHARS
        return result


    def assert_valid_balloon_title(self, value):
        """
        Tests an object for validity as a "title" of a balloon notification.
        Raises TypeError if the given value is not a Unicode string or raises
        ValueError if its length (in characters) exceeds the maximum length
        returned from self.balloon_title_max_length().
        """
        if not isinstance(value, unicode):
            raise TypeError("invalid type for balloon title: {} "
                "(expected unicode)".format(type(value)))

        max_length = self.balloon_title_max_length()
        value_length = len(value)
        if value_length > max_length:
            raise ValueError("invalid length for balloon title: {} "
                "(must be less than or equal to {})"
                .format(value_length, max_length))


    def balloon_title_max_length(self):
        """
        Returns the maximum length (in characters) of the "title" string
        of a balloon notification.  Returns an int.
        """
        result = self.winapi.NOTIFYICONDATA.SZINFOTITLE_NUM_CHARS
        return result


    def assert_valid_balloon_text(self, value):
        """
        Tests an object for validity as a "text" of a balloon notification.
        Raises TypeError if the given value is not a Unicode string or raises
        ValueError if its length (in characters) exceeds the maximum length
        returned from self.balloon_text_max_length().
        """
        if not isinstance(value, unicode):
            raise TypeError("invalid type for balloon text: {} "
                "(expected unicode)".format(type(value)))

        max_length = self.balloon_text_max_length()
        value_length = len(value)
        if value_length > max_length:
            raise ValueError("invalid length for balloon text: {} "
                "(must be less than or equal to {})"
                .format(value_length, max_length))


    def balloon_text_max_length(self):
        """
        Returns the maximum length (in characters) of the "text" string
        of a balloon notification.  Returns an int.
        """
        result = self.winapi.NOTIFYICONDATA.SZINFO_NUM_CHARS
        return result


    def add(self):
        """
        Adds this icon to the notification area.
        Raises Error on error.
        Also, each attribute of this object will be tested for validity using
        the corresponding "assert" method, such as self.assert_valid_id(self.id),
        and any exceptions raised by the "assert" methods will be raised by
        this object.
        """
        self._ShellNotify(NIM_ADD)
        self._ShellNotify(NIM_SETVERSION)


    def remove(self, ignore_errors=False):
        """
        Removes this icon from the notification area.
        Raises Error on error if and only if ignore_errors==False
        (the default); otherwise, errors reported by self.winapi are ignored.
        Also, each attribute of this object will be tested for validity using
        the corresponding "assert" method, such as self.assert_valid_id(self.id),
        and any exceptions raised by the "assert" methods will be raised by
        this object.  These "assertion exceptions" will be raised irrespective
        of the value of ignore_errors.
        """
        self._ShellNotify(NIM_DELETE, ignore_errors=ignore_errors)


    def update(self):
        """
        Changes this icon from the notification area to reflect changes that
        have been made to this object's attributes.
        Raises Error on error.
        Also, each attribute of this object will be tested for validity using
        the corresponding "assert" method, such as self.assert_valid_id(self.id),
        and any exceptions raised by the "assert" methods will be raised by
        this object.
        """
        self._ShellNotify(NIM_MODIFY)


    def _ShellNotify(self, action, ignore_errors=False):
        notifyIconData = self._create_NOTIFYICONDATA(ignore_errors)
        self._invoke_ShellNotify(action, notifyIconData, ignore_errors)


    def _invoke_ShellNotify(self, action, notifyIconData, ignore_errors=False):
        success = self.winapi.Shell_NotifyIcon(action, notifyIconData)
        if not success and not ignore_errors:
            (errno, message) = self.winapi.get_last_error()
            if errno == 0:
                message = "unknown error"
            raise Error("unable to perform requested operation with "
                "notification icon: Shell_NotifyIcon() failed: {} ({})"
                .format(message, errno))


    def _create_NOTIFYICONDATA(self, ignore_errors=False):
        tooltip = self.tooltip
        if tooltip is not None:
            self.assert_valid_tooltip(tooltip)

        guid = self.id
        self.assert_valid_id(guid)

        window_hwnd = self._get_window_hwnd(ignore_errors=ignore_errors)

        uFlags = NIF_GUID | NIF_SHOWTIP
        if tooltip is not None:
            uFlags |= NIF_TIP

        icon = self.icon
        if icon is not None:
            uFlags |= NIF_ICON
            hIcon = icon.handle

        guidItem = self.winapi.GUID()
        guidItem.from_str(guid)

        notifyIconData = self.winapi.NOTIFYICONDATA()
        notifyIconData.cbSize = self.winapi.NOTIFYICONDATA_SIZE
        notifyIconData.hWnd = window_hwnd
        notifyIconData.uFlags = uFlags
        if tooltip is not None:
            notifyIconData.szTip = tooltip
        if icon is not None:
            notifyIconData.hIcon = hIcon
        notifyIconData.guidItem = guidItem

        self._populate_NOTIFYICONDATA_for_balloon(notifyIconData)

        return notifyIconData


    def _populate_NOTIFYICONDATA_for_balloon(self, notifyIconData):
        balloon = self.balloon
        if balloon is None:
            return

        notifyIconData.uFlags |= NIF_INFO

        text = balloon.text
        if text is None:
            text = ""
        else:
            self.assert_valid_balloon_text(text)
        notifyIconData.szInfo = text

        title = balloon.title
        if title is None:
            title = ""
        else:
            self.assert_valid_balloon_title(title)
        notifyIconData.szInfoTitle = title

        if balloon.is_realtime:
            notifyIconData.uFlags |= NIF_REALTIME

        dwInfoFlags = 0

        icon = balloon.icon
        if icon is None:
            dwInfoFlags |= NIIF_NONE
        elif icon == Balloon.ICON_INFO:
            dwInfoFlags |= NIIF_INFO
        elif icon == Balloon.ICON_WARNING:
            dwInfoFlags |= NIIF_WARNING
        elif icon == Balloon.ICON_ERROR:
            dwInfoFlags |= NIIF_ERROR
        elif icon == Balloon.ICON_FROM_OWNER:
            dwInfoFlags |= NIIF_USER
        else:
            dwInfoFlags |= NIIF_USER
            dwInfoFlags |= NIIF_LARGE_ICON
            icon_hwnd = icon.handle
            notifyIconData.hBalloonIcon = icon_hwnd

        if not balloon.sound_enabled:
            dwInfoFlags |= NIIF_NOSOUND

        if balloon.respect_quiet_time_enabled:
            dwInfoFlags |= NIIF_RESPECT_QUIET_TIME

        notifyIconData.dwInfoFlags = dwInfoFlags


    def _get_window_hwnd(self, ignore_errors=False):
        window_hwnd = self.window_hwnd
        if window_hwnd is None:
            window_hwnd = self.winapi.CreateWindowEx(
                dwExStyle=0,
                lpClassName="Static",
                lpWindowName="winnotify.NotifyIcon",
                dwStyle=0,
                X=CW_USEDEFAULT,
                Y=CW_USEDEFAULT,
                nWidth=CW_USEDEFAULT,
                nHeight=CW_USEDEFAULT,
            )
            if window_hwnd == 0:
                if ignore_errors:
                    return
                else:
                    (errno, message) = self.winapi.get_last_error()
                    raise Error("unable to create window to "
                        "receive messages for the notification icon: "
                        "CreateWindowEx failed: {} ({})".format(errno, message))

            self.window_hwnd = window_hwnd

        return window_hwnd
